JavaScript 执行上下文

执行上下文

代码执行时会进入一种执行环境(作用域)中,JavaScript 中代码的执行环境由代码的类型决定,代码的类型有以下三种:

  1. 全局代码
    全局代码在执行时会进入全局执行环境,全局执行环境是最外围的执行环境,是 JavaScript 代码开始执行时的默认执行环境
  2. 函数代码
    函数代码在执行时会进入函数执行环境
  3. eval 代码
    使用 eval() 执行代码时会进入 eval 执行环境

JavaScript 中有一个专门的概念 执行上下文(Execution Context) 来表示执行环境,所以我们通常所说的执行环境和执行上下文其实是一个东西。下面是 Ecma-262 中关于 Execution Context 的描述:

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. At any point in time, there is at most one execution context that is actually executing code. This is known as the running execution context.

执行上下文是一个抽象的概念,用来跟踪代码的执行计算,规定在此环境中执行的代码所能访问的变量以及其他一些数据。

执行上下文栈

下面是 Ecma-262 中关于 Execution Context Stack 的描述:

The execution context stack is used to track execution contexts. The running execution context is always the top element of this stack. A new execution context is created whenever control is transferred from the executable code associated with the currently running execution context to executable code that is not associated with that execution context. The newly created execution context is pushed onto the stack and becomes the running execution context.

  • 当 javascript 代码文件被浏览器载入后,默认最先进入的是一个全局的执行上下文。代码在全局上下文中执行,有代码正在执行的执行上下文称为 进行中的执行上下文(running execution context),此时全局上下文就是进行中的执行上下文。
  • 当在全局上下文中调用执行一个函数时,程序流 就进入该被调用函数内,此时引擎就会为该函数创建一个新的执行上下文。新的执行上下文变为进行中的执行上下文。这个函数执行上下文还可以激活另一个新的执行上下文,以此类推。
  • 这些相互关联的执行上下文从逻辑上构成了一个执行上下文栈。栈底总是全局执行上下文,被激活的执行上下文在激活执行上下文上面,栈顶是进行中的执行上下文。
  • 浏览器总是执行当前在栈顶部的执行上下文(进行中的执行上下文),一旦执行完毕,该执行上下文就会从栈顶被弹出,进入其下的执行上下文执行代码。这样,栈中的执行上下文就会被依次执行并且弹出栈,直到回到全局的执行上下文。
var a = "global var";
function foo(){
console.log(a);
}
function outerFunc(){
var b = "var in outerFunc";
console.log(b);
function innerFunc(){
var c = "var in innerFunc";
console.log(c);
foo();
}
innerFunc();
}
outerFunc()

上面代码首先进入全局执行上下文,然后依次进入 outerFunc,innerFunc,foo 函数执行上下文,执行上下文栈如下午所示:
call stack

执行上下文详解

我们可以把执行上下文当做一个对象来理解。这个对象有三个重要的属性:变量对象(Variable Object,VO)作用域链(Scope chain)this
下面我们来详细了解一下这三个重要属性:

VO/AO

变量对象 VO

变量对象是与执行上下文相关的数据作用域。它是一个与执行上下文对应的特殊对象,其中定义了在执行上下文中声明的变量和函数。也就是说变量对象中包含以下信息:

  • 变量
  • 函数声明
  • 函数的形参

在当前执行上下文中执行的代码,如果试图寻找一个变量,就会首先查找当前执行上下文的 VO。

活动对象 AO

在函数执行上下文中,VO 不能直接被访问,这时 AO 就扮演了 VO 得角色,也就是说在全局执行上下文中创建的是 VO,在函数执行上下文中创建的是 AO。只不过 AO 中除了 VO 中包含的变量、函数声明和函数形参外,多了一个 arguments属性,这个属性保存的是一个对象,这个对象都包括以下属性:

  • index 函数的实参的数字索引,值对应的是实参值
  • length 真正传递的参数值(传入的实参数可能比形参多或少)的数目
  • callee 指向当前函数的引用

执行上下文
我们已经知道,每当一个函数被调用的时候,JavaScript 解释器都会为这个函数创建一个执行上下文,这是在函数内部代码被执行前进行的。在函数体内代码执行时,函数执行上下文会更新。我们看一下这两个阶段执行上下文发生了什么:

  1. 执行上下文创建阶段
    • 创建作用域链(Scope chain)
    • 创建变量对象(VO)/ 活动对象(AO)(variable, functions and arguments)
    • 设置 this 值
  2. 执行上下文更新阶段
    • 指派变量的值和函数的引用,解释执行代码

这两个阶段的区别主要体现在 VO/AO 上

在执行上下文创建阶段,创建 VO/AO 这一步 JavaScript 引擎主要做了下面的事情:

  • 根据函数的参数,创建并初始化普通参数和 arguments 属性
  • 扫描函数内部代码,查找变量声明
    • 对于所有找到的变量声明,将变量名存入 VO/AO 中,相当于不进行初始化(相当于初始化为 “undefined” )
    • 如果变量名称和已经声明的形参和函数声明重复,变量声明不会干扰之前的声明
  • 扫描函数内部代码,查找函数声明
    • 对于所有找到的函数声明,将函数名存入 VO/AO 中,并初始化为函数引用
    • 如果 VO/AO 中已经有重名的函数,进行覆盖

在执行上下文更新阶段,为存入 VO/AO 中的声明变量指定值和函数引用, 然后解释/执行代码,从这里我们可以更好地理解上篇文章中的变量和函数声明的提升

我们通过一段代码来看一下:

function foo(i) {
var a = 'hello';
var b = function privateB() {
};
function c() {
}
}
foo(22);

对于上面一段代码,在执行上下文创建阶段,可以得到如下的 AO:

fooExecutionContext = {
scopeChain: {.....},
activationObject: {
c: pointer to function c() {},
a: "undefined",
b: "undefined",
arguments: {
0: 22,
length: 1,
callee: pointer to function foo() {}
}
},
this: { ... }
}

在执行上下文更新阶段,可以得到如下的 AO:

fooExecutionContext = {
scopeChain: {.....},
activationObject: {
c: pointer to function c() {},
a: 'hello',
b: pointer to function privateB() {},
arguments: {
0: 22,
length: 1,
callee: pointer to function foo() {}
}
},
this: { ... }
}

作用域链(Scope chain)

现在我们知道,在每一个执行上下文中都有一个 VO/AO,用来存放变量,函数和参数等信息。

在 JavaScript 代码运行中,所有用到的变量都要先去当前的 VO/AO 中查找,当找不到的时候,就会继续查找上层执行上下文中的 VO/AO。这样一级级向上查找的过程,就组成了一个作用域链。

所以说,作用域链与一个执行上下文相关,是当前执行上下文 VO/AO 和所有父 VO/AO(执行上下文栈中在当前执行上下文下面的执行上下文的 VO/AO)的列表,用于变量查询。
Scope chain = VO/AO + all parent VO/AO

需要注意的一点是,VO/AO 都是基于函数被定义时的作用域,而不是函数被执行时的作用域。也正如 JavaScript 权威指南中那句经典的描述:“JavaScript 中的函数运行在它们被定义是的作用域中,而不是它们被执行时的作用域中”

我们通过一段代码来了解一下:

var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 30;
console.log(x + y + z);
};
bar()
};
foo();

下图紫色方框内就是 bar 函数执行上下文的作用域链
Scope Chain

这里就引出了一个 闭包 的概念
JavaScript 高级程序设计中对闭包是这样定义的:

闭包是指有权访问另一个函数作用域中的变量的函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数。

一般来说,当一个函数被执行完以后,在这个函数内声明的局部变量都会被垃圾回收机制销毁。

但是,当一个函数执行的结果是返回其子函数(闭包),子函数(闭包)会引用父函数的整个活动对象,即使子函数没有直接引用活动对象中的变量,活动对象中任然会保存一个引用(采用引用计数垃圾收集机制的 IE9 之前的 IE 浏览器会有内存泄漏的问题)。

在父函数体外调用这个子函数时,这个子函数仍然能访问父函数中定义的局部变量(保持其作用域链中的所有变量对象)。也就是说父函数虽然已经执行完了,但是其声明的变量并没有被垃圾回收机制回收掉,跟子函数一起形成了一个闭包,仍然保存在内存中,而且可以被子函数访问。

在后台执行环境中,闭包的作用域链包含着它自己的作用域,父函数的作用域和全局作用域。

我们来看一个例子:

var a = function () {
var x = 1;
return function b(y) {
console.log(x + y);
}
}
var x = 3;
var c = a();
c(2); // 3

a 函数执行完以后,理论上在其函数体内声明的 x 会被销毁。但是其子函数 b 被返回并且引用了这个 x变量,b 函数跟 x 一起构成一个闭包。当在函数体外执行子函数的引用时,子函数访问的是闭包内的 x 变量。

this

this 属性涉及的知识比较多,下篇文章将详细讨论

本文参考:
【1】ECMA-262 5th Edition
【2】JavaScript的执行上下文
【3】理解JavaScript的作用域链
【4】Javascript作用域原理